fix(navigation): reuse router.prefetch payloads during navigation - #2709
Conversation
link.tsx is a "use client" React module, so its prefetch policy helpers (resolveAutoAppRoutePrefetch, resolveFullAppRoutePrefetch and their supporting trie/route-href logic) could not be reached from navigation.ts, which must stay importable without React. Move them verbatim into shims/internal/app-route-prefetch-policy.ts (same layering as internal/app-route-detection.ts) and name the previously inline policy shape AppRoutePrefetchPolicy. link.tsx re-exports canAutoPrefetchFullAppRoute and resolveAutoAppRoutePrefetch, keeping its public test surface unchanged. No behaviour change.
router.prefetch() stored every response as a learning-only cache entry (cacheForNavigation: false), so a router.prefetch(x) followed by router.push(x) always issued a second RSC request; even an in-flight programmatic prefetch was invisible to navigation. Next.js reuses router.prefetch() results (PrefetchKind.AUTO by default, kind: "full" caches the full payload), and <Link> prefetches in vinext already produce reusable entries through the shared route-policy helpers. Resolve the same policy in router.prefetch(): kind: "full" maps to the full-prefetch policy, anything else to the auto policy for the target route. Reusable prefetches send the same segment-prefetch headers as <Link>, gate re-issue on a freshness-aware navigation cache probe (discarding a stale learning-only twin first), and store the payload with navigation TTLs plus ahead-of-navigation snapshot preparation. Routes the auto policy declines (no manifest match, loading shell, search params) keep the previous learning-only fetch, preserving optimistic-route-template learning and matching Next.js AUTO semantics for dynamic routes. In-flight sharing needs no extra machinery: consumePrefetchResponseForNavigation already awaits a pending entry once the entry is no longer learning-only. Fixes cloudflare#2707
Covers issue cloudflare#2707 end to end on the /top -> /film/[imdbId] interception fixture: a programmatic router.prefetch followed by a Link click must produce exactly one /film RSC request while still rendering the intercepted film panel. A companion test without the prefetch pins the request counter so the reuse assertion cannot pass vacuously.
|
@codex review |
commit: |
Performance benchmarksCompared 0 improved · 0 regressed · 6 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a587ba57b0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…h reuse Address review findings on the router.prefetch reuse path: - A configured client rewrite can map the prefetched href onto a different App route. The policy previously described the source pattern, so a destination with a loading boundary or a requires-dynamic flag was incorrectly marked reusable. Load the rewrite-aware hybrid module first and resolve the policy (and the freshness-gate lookup) against the rewritten destination, mirroring Link's prefetchPolicyHref. - When the freshness gate matched a reusable entry under a normalized _rsc variant or rendered-path alias, onInvalidate was attached via an exact-key lookup that silently missed, so the documented callback never fired on expiry. hasPrefetchCacheEntryForNavigation now attaches the callback to whichever entry it matched. - Import the prefetch policy module dynamically inside the prefetch closure so its route-trie dependencies stay off the startup path of every next/navigation consumer (AGENTS.md performance guidance).
|
@codex review |
|
Codex Review: Didn't find any major issues. Can't wait for the next one! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
…tion
Two lifecycle gaps in the router.prefetch navigation-reuse path:
`router.prefetch()` awaits hybrid-route loading, the lazily imported
prefetch policy module, and RSC URL generation before it registers
anything in the prefetch cache. A navigation starting in the same task
finds nothing to share, issues its own request, and the late prefetch
then starts a second one. Track a navigation epoch alongside the
existing link-navigation-start signal and bail out of prefetch setup
when a navigation won the race, mirroring link.tsx's guard.
Consuming a prefetched response deleted the entry with notify=false,
which discarded `onInvalidate` without ever calling it -- so
`prefetch(href, { onInvalidate })` followed by `push(href)` silently
lost the subscription. Retain the callbacks past consumption so they
still fire once when the original stale window elapses or the prefetch
cache is invalidated, matching Next.js's prefetch-task contract. An
entry that aged out before navigation reached it now notifies instead
of dropping the callback.
|
@codex review |
|
Codex Review: Didn't find any major issues. 🚀 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
No behavior change. `<Link>` and `router.prefetch()` carried byte-identical `prepareSnapshot` closures; extract `prepareNavigationPrefetchSnapshot` and share it. `resolveAutoAppRoutePrefetch` repeated the same five-field "no prefetch" literal at four early returns; collapse them to a single NO_APP_ROUTE_PREFETCH constant. Every call site reads the resolved policy without mutating or spreading it, so one shared object is safe. Fold `resolveMatchedAutoAppRoutePrefetch` and its post-hoc search-param override into one object literal: `prefetchShellFirst` becomes `hasSearchParams || !isDynamic`, matching the previous unconditional `true` on the search branch and `!isDynamic` otherwise. `canAutoPrefetchFullAppRoute` re-ran the window / manifest / href / trie-match checks before delegating; each of those failures already resolves to `cacheForNavigation: false`, so drop them. The policy module stays behind a dynamic import inside the prefetch closure, keeping its route-trie dependencies off the next/navigation startup path.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4282cfb662
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…idation
Two more lifecycle gaps in the router.prefetch reuse path.
Upgrading a learning-only prefetch to a navigation-reusable one
(`router.prefetch(href)` then `router.prefetch(href, { kind: "full" })`,
or the equivalent <Link> transition) discarded the old entry with
notifications suppressed, so `onInvalidate` callbacks registered by the
first call could never run. A superseded prefetch is dirty in Next.js
terms, so notify instead of dropping. Fixed in the shared discard helper
rather than at the router call site: link.tsx reaches the same path on
the same upgrade. Entries are now collected before deletion so a
subscriber that seeds a new prefetch cannot be visited by the loop that
is still iterating the cache.
The prefetch setup epoch advanced only on navigation start, but
`router.refresh()` clears the caches via `clearNavigationCaches` ->
`invalidatePrefetchCache` without starting a navigation. A closure still
awaiting its policy import would resume afterwards and repopulate a
navigation-reusable entry derived from the pre-refresh generation,
undoing the refresh for that route. Advance the epoch on invalidation
too, and move it next to the cache it guards.
|
@codex review |
|
Codex Review: Didn't find any major issues. Can't wait for the next one! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
…ated branches No behavior change. router.prefetch generated the RSC URL and its rewrite variant with two sequential awaits over the same headers; run them together. It also set the mounted-slots header by hand when createAppPrefetchRequestHeaders already accepts `mountedSlotsHeader`. The option sets the header for any non-null value while the call site skipped empty strings, so pass `|| null` to keep an empty slot header off the wire. link.tsx built a `full-after-shell` policy literal byte-identical to resolveFullAppRoutePrefetch(), which the policy extraction had already made shared and which the file already imports; both arms of the ternary now resolve the same way, so the branch collapses. hasPrefetchCacheEntryForNavigation had two byte-identical if bodies and a local closure that existed only to avoid duplicating their shared tail. Merge the conditions, and hoist the callback attachment into attachPrefetchInvalidationToEntry so the key-taking attachPrefetchInvalidationCallback delegates to it instead of repeating the "seeded entries need their invalidation timer scheduled" invariant.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 60575ba102
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Low-priority prefetches share a 4-slot queue whose runners do not start until a previous response body is read. A navigation that reuses an in-flight prefetch awaits that prefetch's promise, so when the request is still queued the navigation waits on unrelated response bodies before its own request starts — indefinitely if one of those streams stalls. Promote on consume: `consumePrefetchResponseForNavigation` now starts a queued request instead of waiting for a slot. A promoted request is no longer a prefetch, it is the navigation, so it bypasses the concurrency cap. Reaching the queued runner needs a handle the queue did not expose, so it keeps a WeakMap from the returned promise to its runner, and the cache entry records the schedulable promise (`entry.pending` is the derived chain, not the promise the queue knows). Pre-existing via <Link>: link.tsx passes cacheForNavigation through and prefetchRscResponse defaults it to true, so low-priority viewport prefetches already produced reusable entries that navigation awaited. Fixed in the shared queue rather than the router path so both benefit. Also drops prefetch cache-entry field assertions that restated the implementation where the same test already asserted the behavior those fields produce (consume returning a payload, or null).
|
@codex review |
|
Codex Review: Didn't find any major issues. Keep it up! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
|
Heads up @james-elicx — I was mid-implementation on the same three findings when you pushed 1. Stripping the fragment needs a same-document guard. Fixed by skipping cancellation for same-document hash changes. The two rules are complementary: the fragment is still stripped so a real navigation to
Verified as a browser case, not a unit one: driving a hash navigation in the unit harness needs a DOM it does not have (I wrote the unit version first and it blew up on 2. CI was red on Local state on the merge: |
Resolve relative prefetch ownership and policy from the call-time URL, matching the already captured request target. Keep abort controls alive until response bodies are consumed so superseded streaming prefetches release bandwidth and queue slots.
|
/bigbonk review for issues |
|
UnknownError: ProviderInitError |
|
@james-elicx Bonk workflow failed. Check the logs for details. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review for issues |
1 similar comment
|
/bigbonk review for issues |
|
UnknownError: ProviderInitError |
|
/bigbonk review for issues |
|
UnknownError: ProviderInitError |
|
@codex adversarial review |
|
To use Codex here, create an environment for this repo. |
|
@codex review |
|
/bigbonk review for issues |
|
Codex Review: Didn't find any major issues. Chef's kiss. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
There was a problem hiding this comment.
Reviewed the full diff, ran vp check on the four touched source files (clean) and vp test run tests/prefetch-cache.test.ts (59/59 pass).
This is a well-engineered change. The policy extraction to shims/internal/app-route-prefetch-policy.ts is a faithful, behaviour-preserving move (the prefetchShellFirst: hasSearchParams || !route.isDynamic rewrite is equivalent to the old two-branch form). The hard parts — the abort-aware fetch queue, promotion-on-consume, destination-scoped sticky cancellation, and invalidation-callback retention across consumption — are all consistent and backed by discriminating tests. The concurrency accounting in app-prefetch-fetch-queue.ts balances correctly across the promote/cancel/drain permutations I traced (promote-then-cancel, cancel-then-promote, and drain-after-run all no-op or net-zero the active counter).
The prior Codex rounds surfaced the substantive concerns (rewrite-aware policy, lazy policy import, HAS_CLIENT_REWRITES gating of the ownership preload, epoch to destination scoping, shallow-history and hash-only guards) and they've each been addressed with a regression test. I only have two minor, non-blocking observations below.
| // Each callback removes its own record before running, which Set iteration | ||
| // tolerates; a record retained by a callback is fired too, which is the | ||
| // correct outcome for a full cache invalidation. | ||
| for (const retained of retainedPrefetchInvalidations) { |
There was a problem hiding this comment.
Non-blocking observation: iterating retainedPrefetchInvalidations while fireRetainedPrefetchInvalidation deletes from it is safe, but if a fired onInvalidate callback synchronously triggers router.push(x) -> consume -> retainPrefetchInvalidationAfterConsume, the newly-added record is inserted into the Set during this for...of and will therefore be visited and fired in the same invalidation pass (its TTL effectively ignored). The comment above frames this as "the correct outcome for a full cache invalidation," which is defensible — just flagging that the behaviour depends on insertion timing during iteration. A snapshot copy ([...retainedPrefetchInvalidations]) before the loop would make it timing-independent if that re-entrant case is ever not desired.
| * | ||
| * No-op when the request has already started or was never queued. | ||
| */ | ||
| export function promoteAppPrefetchFetch(promise: Promise<Response> | undefined): void { |
There was a problem hiding this comment.
Non-blocking clarity nit: the doc says a promoted request "bypasses the concurrency cap," but the implementation does activeDefaultAppPrefetchRequests += 1 before running (line 158), so a promoted 5th request does consume a slot and pushes the active count to 5 — one over the cap until it releases. That's the correct behaviour; it just isn't literally bypassing the counter, it's temporarily exceeding the cap by one. Worth a one-line tweak so a future reader doesn't expect the counter to be untouched.
|
Review submitted successfully. SummaryI reviewed PR #2709 (
Findings: No blocking issues. The substantive concerns were already caught and fixed across the many prior Codex rounds (each with a regression test). I posted a
Both are nits; the PR is approvable-quality work. |
|
/bigbonk review for issues |
|
UnknownError: ProviderInitError |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed the full diff across all 7 files, traced the abort-aware queue accounting (app-prefetch-fetch-queue.ts) through promote/cancel/drain permutations, verified the destination-scoped sticky cancellation and hash-only/shallow-history guards in navigation.ts, and confirmed the policy extraction to app-route-prefetch-policy.ts is behaviour-preserving (prefetchShellFirst: hasSearchParams || !route.isDynamic and the cacheForNavigation composition both reduce to the old two-branch form). Fixtures (#godfather-film-link, film-panel, no loading.tsx on /film/[imdbId]) all line up with the e2e expectations.
This is careful, well-tested work and the substantive concurrency/lifecycle concerns were already surfaced and fixed with discriminating regression tests in the prior rounds. I confirmed a few things that could have been bugs but are actually correct:
- The abort-mid-stream path (
cancelAppPrefetchFetchon a resolved-but-streaming response) does not leak a queue slot:snapshotRscResponse'sfinallyreleases the slot even whenresponse.arrayBuffer()rejects on abort, so the outer.catchnot callingreleaseAppPrefetchFetchSlotis fine. - Double-promote and promote-after-start are both no-ops (
indexOf(runner) === -1), so the active counter stays balanced. - The new release-key on high-priority responses only deletes the control map entry and never touches
activeDefaultAppPrefetchRequests, so high-priority accounting is unchanged. - The setup token is registered synchronously before the async closure, so a same-task
router.push(x)reliably cancels a same-taskrouter.prefetch(x).
No blocking issues. Two minor, non-blocking observations inline.
| * promise. When the request is only queued, the navigation would otherwise wait | ||
| * for unrelated prefetch response bodies to finish before its own request even | ||
| * starts — indefinitely if one of those streams stalls. A promoted request is | ||
| * no longer a prefetch, it is the navigation, so it bypasses the concurrency |
There was a problem hiding this comment.
Non-blocking wording nit: the doc says a promoted request "bypasses the concurrency cap instead of waiting for a slot," but the implementation does activeDefaultAppPrefetchRequests += 1 (line 158) before running, so a promoted 5th request consumes a slot and pushes the active count to 5 — one over the cap until it releases. The behaviour is correct (it's counted so the next drainDefaultAppPrefetchQueue won't over-schedule), it just doesn't literally bypass the counter. Worth a one-line clarification so a future reader doesn't expect the counter to be left untouched.
| deletePrefetchCacheEntry(cache, prefetched, cacheKey, entry, true); | ||
| } | ||
| prefetched.clear(); | ||
| // Each callback removes its own record before running, which Set iteration |
There was a problem hiding this comment.
Non-blocking observation on invalidatePrefetchCache: iterating retainedPrefetchInvalidations while fireRetainedPrefetchInvalidation deletes from it is safe, but if a fired onInvalidate callback synchronously triggers router.push(x) -> consume -> retainPrefetchInvalidationAfterConsume, the newly-inserted record is added to the Set during this for...of and will therefore be visited and fired in the same pass, ignoring its TTL. The comment frames this as "the correct outcome for a full cache invalidation," which is defensible, but the behaviour is timing-dependent on insertion-during-iteration. A snapshot copy (for (const retained of [...retainedPrefetchInvalidations])) would make it deterministic if that re-entrant case is ever unwanted.
|
Review submitted successfully. SummaryI reviewed PR #2709 (
Findings: No blocking issues. I specifically checked for bugs and found several near-misses that are actually correct:
I posted a
The substantive concurrency/lifecycle concerns were already caught and fixed across the prior Codex rounds, each with a regression test. The PR is approvable-quality work. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed the full diff across all 7 files. Traced the abort-aware fetch queue accounting in app-prefetch-fetch-queue.ts (promote / cancel / drain permutations all balance the active counter), verified the destination-scoped sticky cancellation and hash-only / shallow-history guards in navigation.ts, and confirmed the policy extraction to app-route-prefetch-policy.ts is behaviour-preserving.
Policy extraction is faithful. The old resolveAutoAppRoutePrefetch special-cased search params in a trailing if (url.search !== "") branch (cacheForNavigation: false, prefetchShellFirst: true); the new form folds that into cacheForNavigation: !hasSearchParams && !canPrefetchLoadingShell && requiresDynamicNavigationRequest !== true and prefetchShellFirst: hasSearchParams || !route.isDynamic. Both reduce to the original two-branch result for every input, and minimumTtlMs / fallbackTtl are unchanged. canAutoPrefetchFullAppRoute dropping its redundant pre-match check is equivalent because resolveAutoAppRoutePrefetch already returns NO_APP_ROUTE_PREFETCH (cacheForNavigation: false) on every no-match path.
Header parity with <Link> holds. The reusable auto path sets Next-Router-Prefetch: 1 + segment header, matching Link's mode === "auto" branch; the kind: "full" path deletes Next-Router-Prefetch, matching <Link prefetch={true}>; the learning-only fallback preserves the original router.prefetch header shape (Next-Router-Prefetch: 1, no segment header). The mounted-slots header now flows through the createAppPrefetchRequestHeaders option instead of a separate .set(), which is equivalent (empty string still skipped).
Queue accounting is sound. Promote-then-cancel, cancel-then-promote, and drain-after-promote all no-op or net-zero the active counter (indexOf(runner) === -1 guards double-promote / promote-after-start). The abort-mid-stream path releases the slot via snapshotRscResponse's finally, and the .finally in prefetchRscResponse clears fetchPromise alongside pending so a settled entry can't be promoted.
vp-style unit run: vitest run tests/prefetch-cache.test.ts → 59/59 pass. Fixtures (#godfather-film-link, film-panel, no loading.tsx on /film/[imdbId]) line up with the e2e expectations.
No blocking issues. Two minor, non-blocking observations inline (both already noted in prior rounds).
| // Each callback removes its own record before running, which Set iteration | ||
| // tolerates; a record retained by a callback is fired too, which is the | ||
| // correct outcome for a full cache invalidation. | ||
| for (const retained of retainedPrefetchInvalidations) { |
There was a problem hiding this comment.
Non-blocking, re-flagging the prior observation: fireRetainedPrefetchInvalidation deletes each record before firing, so plain iteration is safe. But if a fired onInvalidate synchronously triggers router.push(x) -> consume -> retainPrefetchInvalidationAfterConsume, the freshly-inserted record lands in the Set during this for...of and — per ES Set-iteration semantics — is visited and fired in the same pass, ignoring its TTL. The comment frames this as the correct outcome for a full invalidation, which is defensible; it's just timing-dependent on insertion-during-iteration. A snapshot copy (for (const retained of [...retainedPrefetchInvalidations])) would make it deterministic if the re-entrant case is ever unwanted.
| * promise. When the request is only queued, the navigation would otherwise wait | ||
| * for unrelated prefetch response bodies to finish before its own request even | ||
| * starts — indefinitely if one of those streams stalls. A promoted request is | ||
| * no longer a prefetch, it is the navigation, so it bypasses the concurrency |
There was a problem hiding this comment.
Non-blocking wording nit: the doc says a promoted request "bypasses the concurrency cap instead of waiting for a slot," but the implementation does activeDefaultAppPrefetchRequests += 1 (line 158) before running, so a promoted 5th request consumes a slot and temporarily pushes the active count to 5 — one over the cap until it releases. The behaviour is correct (it's counted, so the next drainDefaultAppPrefetchQueue won't over-schedule); it just doesn't literally leave the counter untouched. Worth a one-line clarification so a future reader doesn't expect the counter to be bypassed.
|
Review submitted successfully to PR #2709. SummaryI reviewed PR #2709 (
Findings: No blocking issues. The PR has been through an exceptional number of prior review rounds (many Codex + bonk cycles), and every substantive concurrency/lifecycle concern (rewrite-aware policy, lazy import, epoch → destination scoping, shallow-history/hash-only guards, queue promotion-on-consume, invalidation-callback retention) has already been addressed with a discriminating regression test. I posted a |
Overview
router.prefetch()results reusable by a subsequent navigation_appRouter.prefetchresolves the same prefetch policy as<Link>instead of always storing a learning-only entryshims/internal/app-route-prefetch-policy.tssonavigation.ts(no React) andlink.tsx("use client") share one decisionrouter.prefetch(x)+router.push(x)/ Link click issues one RSC request instead of two; in-flight programmatic prefetches are shared with navigationCloses #2707.
Why
Every prefetched payload must either be safe to commit on navigation or be marked learning-only.
router.prefetch()hardcoded the learning-only shape ({ cacheForNavigation: false, optimisticRouteShell: true }), so all navigation-reuse paths (findPrefetchCacheEntryForNavigation,consumeMatchedPrefetchResponse,peekPrefetchResponseForNavigation) skipped its entries by design, and every navigation refetched. Next.js reusesrouter.prefetch()results: it defaults toPrefetchKind.AUTOand caches the full payload forkind: "full"(app-router-instance.ts).vinext already encodes exactly that reuse decision for
<Link>inresolveAutoAppRoutePrefetch/resolveFullAppRoutePrefetch, keyed off the client prefetch route manifest. The right owner of the decision is that shared policy, not a second hardcoded literal in the router, so this PR moves the helpers to an internal module and hasrouter.prefetchconsult them.What changed
router.prefetch(x)then navigate tox(route without loading shell / search params)router.prefetch(x)is still in flightconsumePrefetchResponseForNavigationpathrouter.prefetch(x, { kind: "full" })Next-Router-Prefetchsuppressed on the wire (matches<Link prefetch={true}>)getPrefetchInterceptionContext, which already keys prefetch and navigation identically (covered by the new e2e on the/top→/film/[imdbId]modal fixture)<Link>prefetching,form.tsxprefetchinglink.tsxre-exports the moved helpers so its public test surface is unchangedMaintainer review path
packages/vinext/src/shims/internal/app-route-prefetch-policy.ts: verbatim extraction fromlink.tsx(first commit is mechanical only).packages/vinext/src/shims/navigation.ts(_appRouter.prefetch): the behavioural decision — kind resolution, policy lookup, reusable vs learning branch, freshness gate (discardLearningOnlyPrefetchCacheEntry+hasPrefetchCacheEntryForNavigation), and the reusableprefetchRscResponsebehaviour object mirroring Link's.tests/prefetch-cache.test.ts: unit proof for reuse, in-flight sharing,kind: "full", and the preserved learning-only fallback.tests/e2e/app-router/router-prefetch-reuse.spec.ts: end-to-end request counting across an intercepted navigation.Validation
kind: "full"reusable on a loading-shell route withNext-Router-Prefetchabsent; loading-shell route stays learning-only under the default kind.app-router, dev):router.prefetch+ click on the film interception fixture produces exactly one/film/*RSC request and still renders the intercepted panel; a companion no-prefetch test pins the request counter.vp checkclean on all touched files.Commands
Risk / compatibility
window.next.router.prefetchgains the documented Next.jskindoption;PrefetchOptions.kindstaysunknownat the shim boundary and anything other than"full"falls back to auto, matching Next'sdefault:branch.router.prefetch). Dev-mode e2e that depend on per-navigation requests are protected by the loading-shell policy and were re-run.minimumTtlMs: 0honours server stale-times,PREFETCH_CACHE_TTLfallback; encoded intests/link-navigation.test.tsstale-time tests).link.tsx/prefetchRscResponseregion as open PRs fix(app-router): support instant route prefetch shells #2374 and fix(app-router): full prefetch static loading routes #2446. The extraction commit is a verbatim move to keep rebases cheap; this PR depends on neither.Non-goals
fetchLoadingShellForReuse/ full-after-shell sequencing,searchAgnosticShellprobes, and route-tree gating are not replicated forrouter.prefetch. Those optimise viewport/hover races that do not apply to a programmatic call; an in-flightrouter.prefetchentry is already shared with navigation by awaitingentry.pending.Router.prefetchorform.tsxprefetching.References
router.prefetch+router.pushalways double-fetchesapp-router-instance.ts(prefetch, lines 392-438)kind: "full", navigation reuse